Skip to content

feat(objectql): the two transaction-seam refusals publish their error code as a constant - #16326

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-16159-transaction-error-code-constants
Sep 6, 2026
Merged

feat(objectql): the two transaction-seam refusals publish their error code as a constant#16326
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-16159-transaction-error-code-constants

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Part of #16159

Batch ii of that card's sweep, per the batching ruling recorded on it: the last two mechanical rows of its table, in one file. TransactionUnsupportedError and CrossDatasourceTransactionWriteError each publish their ADR-0112 code as an importable constant, matching the shape set by #16283 and #16259.

Authored by the domain:engine execution seat in session session_01ARYe3yQTQCUFm5qPYNgKaJ, branch claude/issue-16159-transaction-error-code-constants.

⛔ Deliberately NOT in this PR, and untouched: packages/objectql/src/validation/record-validator.ts (1 row — held until VALIDATION_FAILED vs VALIDATION_ERROR is ruled; folding an unruled naming question into a mechanical sweep is what the card's slicing exists to prevent) and content/docs/** (#16281 tracks the doc sweep — see the docs census below, which came back non-zero and still owes no edit).

⚠️ This runs in parallel with PR #16308 (batch i), not behind it. The batch-i round measured check:single-claim-paths and found its SINGLE_CLAIM_PATHS list holds exactly one entry, .objectui-sha; the barrel is not a gate-declared single-writer path, so no gate serialises the two. The only coupling is textual, and this PR's barrel edits sit 6+ lines away from #16308's nearest hunk with no reflow of its lines.

Why these two rows

packages/objectql/src/transaction-errors.ts opens by telling its reader that the errors in it "identify themselves by a code field rather than by instanceof, for the reason DriverConnectError already records: the check has to survive crossing a package boundary, where two copies of this module can exist" — and offered nothing to import. The barrel repeats the instruction twice more, once per class: "code is the boundary-crossing identity" and "code crosses package boundaries". The only way to FOLLOW that published instruction was to re-spell the wire string in the consumer's own package, which acquires a check:error-code-provenance stamp site there and is then free to drift from what the engine throws with no compile error to say so.

⭐ Row 2 is the strongest case on the card so far for the affordance being used rather than merely available: CrossDatasourceTransactionWriteError's own message prescribes a remedy — "split the work into per-datasource units and have the caller reconcile them explicitly" — which is code a caller writes around this refusal, and therefore code that has to recognise it first.

⚠️ Unlike batch i, there is no live first-party consumer re-spelling to point at, and the census is worth stating precisely rather than as a zero. Outside packages/objectql and packages/spec, exactly two files in the repo name either code, and neither is a consumer recognising the refusal:

site what it is rewire it?
packages/runtime/src/dispatcher-error-vocabulary.ts:267 a comment recording the #8846 discharge history, inside UNREGISTERED_CODE_SITES' docblock no — not a stamping site, and the gate's output is byte-identical across this change
packages/runtime/src/error-envelope.conformance.test.ts:330 and :332 the #8846 discharge pin, whose own comment says the seven codes are "Hand-spelled HERE deliberately: registration makes the scan skip them, so no derivation carries them any more" no — the hand spelling is the pin's mechanism; packages/runtime already declares @objectstack/objectql: workspace:*, so importing was always available and was deliberately declined

⇒ The argument for these two rows is the published instruction and the realm split, not a shipped consumer cost. That is a weaker case than batch i's and it is stated as such.

Clause ② — graded per row, from a measurement

Batching changed how many PRs the sweep costs, ⛔ not how each row is graded. Instrument: build at head, swap both changed sources back to their base, rebuild, and compare exported NAME SETS across every declaration file files[] publishes (⛔ not bytes alone: tsup renumbers internal aliases and the shared chunk's filename is content-hashed).

declaration file names BASE ⇒ HEAD bytes reading
dist/index.d.ts 252 ⇒ 254 differ added exactly the two constants, removed none
dist/index.d.mts 252 ⇒ 254 differ identical delta on the other realm
dist/core.d.ts / .d.mts 79 ⇒ 79 byte-identical the lean entry publishes nothing new
shared chunk util-zFBRz_yg.d.ts / .d.mts 133 ⇒ 133 byte-identical ⭐ and the content-hashed filename does not move either

Union over all six published declaration files: 386 ⇒ 388 names, ADDED = the two constants, REMOVED = empty.

row new export Clause ② bump
transaction-errors.tsERR_TRANSACTION_UNSUPPORTED TRANSACTION_UNSUPPORTED_CODE yes — additive widening, nothing removed minor
transaction-errors.tsERR_CROSS_DATASOURCE_TRANSACTION_WRITE CROSS_DATASOURCE_TRANSACTION_WRITE_CODE yes — additive widening, nothing removed minor

Each row is separable in that measurement rather than lumped: each constant appears by name in the ADDED set of both realms, and REMOVED is empty everywhere.

Four of the six published declaration files are byte-identical across the ablation, which is the stronger statement a name set cannot make: it forecloses JSDoc and member-signature movement on core and on the shared chunk too. Both constants are declared directly in index.d.ts, not through the shared chunk — the opposite of batch i, where two of three reached the barrel only via the chunk.

Ablation hygiene, since these numbers depend on it: implementation committed first; each whole-line anchor proved unique in both directions before anything ran (HEAD new=1 old=0 · BASE new=0 old=1, per row); mutation proven on disk (each path's git hash-object equal to the BASE blob, plus the anchored counts flipped to new=0 old=1); each leg rebuilt with the package's own tsup invoked directly, no turbo cache on the path, and the rebuild proven by dist/index.d.ts mtime moving 1788702203060 ⇒ 1788702393524 ⇒ 1788702414656; restore proven byte-exact (git hash-object equal to the HEAD blob for both paths, git diff HEAD empty, git status --porcelain empty ⇒ zero blast radius); and the restored build's six declaration files are byte-identical hashes to the head snapshot, so the base leg was a measurement and not a blind spot.

⚠️ One aborted run is recorded rather than banked: the first attempt exited on its own anchor-uniqueness proof because the counting helper double-printed on a zero count. Nothing was mutated, the tree was verified clean, the helper was fixed and the ablation re-run from the start.

The two inverted gates, measured rather than predicted

Both codes are registered in ERROR_CODE_LEDGER under @objectstack/objectql (packages/spec/src/api/error-code-ledger.zod.ts), like #16283's and #16308's rows and unlike #16259's. The prediction that follows was checked on both sides of the change, not assumed:

  • check:error-code-provenance is NOT neutral. 311 ⇒ 313 registered-code stamp sites, 295 ⇒ 297 listed, waivers unchanged at 16, scanned 2176 files both sides, exit 0 both sides. Exactly +1 per row: each new constdef is a stamp site this gate sees, and it passes because the code is listed under this package's own owner key.
  • check:dispatcher-error-vocabulary does not move. The two runs' output is byte-identical (diff of the full logs, banner excluded: 0 lines) — 66 unregistered code-stamping sites all classified, 2372 non-test source files, 303 registered codes, exit 0 both. That table records UNREGISTERED sites, so a registered code is invisible to it by construction, and no row in packages/runtime/src/dispatcher-error-vocabulary.ts changes. No cross-package edit is owed for either row.

Shape, and one deliberate deviation from batch i

Follows #16283: export const X_CODE = '…' as const; beside its class, the class field as a bare readonly code = X_CODE; (⛔ no as const on the FIELD — check:dispatcher-error-vocabulary's classconst pattern requires the constant name to be followed by ;, , or a newline, so the suffix would take the site out of it), re-exported from index.ts beside the class. Each string is byte-identical to the literal it replaces: the quoted wire spelling occurs exactly once per code in the file on both sides — it moved, it did not multiply or mutate.

The deviation: each constant and its docblock sit ABOVE the class's own docblock, not between that docblock and the class. Measured on the real emitter, on this branch, with a firing counterfactual: with the constant interposed (PR #16308's placement), tsup emits declare class TransactionUnsupportedError with zero lines between it and the constant — the class's own documentation is emitted onto the CONSTANT and the class ships undocumented. With the constant above, both keep their own docblock in the published dist/index.d.ts. A standalone tsc --declaration probe gives the same answer and explains it: two consecutive JSDoc blocks both attach to the declaration that follows them. The grouped shape #16259 landed in registry.ts already avoids this. The counterfactual leg was restored byte-exact and the tree rebuilt to a dist byte-identical to the head snapshot.

⚠️ That is a live observation about #16308, which is open and unmerged and whose three classes all carry real docblocks; it is review feedback on that PR rather than a defect on origin/main, so it is reported to the dispatching seat and not filed as a card.

Neither class in this file is published from the lean ./core entry (control: core.ts names DriverConnectError, and names no Transaction symbol at all), so the batteries-only placement of these constants introduces no asymmetry here and adds nothing to #16260's population — class and constant are reachable from exactly the same entry point. That is a different reading from batch i, where two of the three classes ARE on ./core.

Verification

  • Tests — two new pin files, one per row (transaction-unsupported-code-constant.test.ts, cross-datasource-transaction-write-code-constant.test.ts), five cases each: the literal wire string (the byte-identity fence, deliberately NOT a constant compare — a pin that reads the constant cannot catch the constant being wrong), the code a real refusal carries asserted with name and the discriminating payload (⛔ never a bare toThrow()), barrel reachability by identity, barrel-class/barrel-constant agreement, and a cross-realm copy where instanceof is false while the code compare holds. Row 2's case 2 drives all three write operations, pinning that the three messages differ while the three codes are equal. ⚠️ Neither class carries a status field, so ADR-0112's code + status minimum reduces to code plus the discriminating fields; ⛔ inventing a status would be new published surface and is not what this card converts.
  • pnpm --filter @objectstack/objectql exec vitest run over those two plus the four existing suites that exercise these refusals — 6 files / 65 tests passed, exit 0.
  • pnpm --filter @objectstack/objectql typecheck — exit 0, including check:test-typecheck: the test-layer debt ledger holds at 44 files / 242 errors / 69 pinned signatures, unchanged. Both new test files are proven inside that program by tsc --noEmit --listFiles -p tsconfig.test.json (1 hit each; firing control 1, negative control 0) and contribute 0 of the 242 — the 242 error lines in the same log are the firing control for that grep.
  • Gate families — derived from the actual diff by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (5 paths vs merge base 1ca95df16), 61 runnable commands, 57 exit 0, each captured redirect-then-read and ⛔ never through a pipe. The four that did not are NOT MEASURED rather than red, and none is about this diff: check:dual-build-cjs-loads, check:published-readme-exports and check:type-check-debt returned exit 3 PREREQUISITE NOT MET (they read built output), and check:react-declaration-parity printed "MANIFEST is not set — this gate did NOT run" (it needs an objectui build and a browser).
  • After a full workspace build (71/71 tasks) all three exit-3 gates are real greens over a population that includes this package: check:dual-build-cjs-loads exit 0 (103 published require entry points across 66 packages, 619 emitted CommonJS files parse); check:published-readme-exports exit 0 (324 documented symbols); check:type-check-debt exit 0 (12 ledger entries re-measured in 162s, 140 raw errors, none above its recorded number).
  • ⚠️ check:dts-closure's first green was about a population that mostly excluded this tree — exit 0 while sweeping 15 built packages, because only objectql's dependency closure had been built. Re-run after the workspace build: 71 built packages, 163/163 declared declaration files present, exit 0. Quoting the first reading as evidence would have been the vacuous-green shape.
  • Repo-wide pnpm lint (eslint . --no-inline-config) run IN FULL, exit 0 — ⛔ no narrowing claimed. Non-vacuity control: re-running eslint over exactly the four changed/added TypeScript files with --format json reports 4 files linted, 0 errors, 0 warnings, and the changeset .md is in eslint's population too (1 file linted), so the repo-wide green demonstrably covers all five changed files.
  • Docs census — the zero is not where it was expected. TransactionUnsupportedError, CrossDatasourceTransactionWriteError and both new constant names appear in 0 files under content/docs, but both wire codes appear in 2 files each: references/api/contract.mdx and references/api/error-code-ledger.mdx, which are AUTO-GENERATED enumerations of the ledger vocabulary. No edit is owed — this diff registers no new code and touches no file under packages/spec, so the enumerated vocabulary is unchanged. Firing controls on the same corpus: @objectstack/objectql 12 files, ERR_READONLY_FIELD_REJECTED 3, transaction 42; negative control 0. ⛔ content/docs/releases/** is untouched.

Every reading above was taken at this branch's head.


Generated by Claude Code

…as a constant

`transaction-errors.ts` opens by telling the reader that the errors in it
identify themselves by a `code` field rather than by `instanceof`, "for the
reason `DriverConnectError` already records: the check has to survive crossing a
package boundary, where two copies of this module can exist" -- and offered
nothing to import. Following that published instruction meant re-spelling the
wire string in the consumer's own package, which acquires a
`check:error-code-provenance` stamp site there and is then free to drift from
what this engine throws with no compile error to say so.

`TRANSACTION_UNSUPPORTED_CODE` and `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE`
each hold text byte-identical to the literal they replace: the quoted wire
spelling occurs exactly once per code in this file on both sides of the change,
so it moved rather than multiplied. Both classes were already exported from the
barrel and stay exported; the constants join them there.

Neither class is published from the lean `./core` entry, so the batteries-only
placement of these two constants adds nothing to #16260's population: class and
constant are reachable from exactly the same entry point.

Placement differs deliberately from the sibling batch on this card. Each
constant and its docblock sit ABOVE the class's own docblock rather than between
that docblock and the class. Measured with `tsc --declaration`: two consecutive
JSDoc blocks are both emitted against the declaration that follows, so
interposing the constant moves the class's documentation onto the CONSTANT in
the published `.d.ts` and leaves the class with none. The grouped shape already
landed in `registry.ts` avoids the same thing.

Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Co-authored-by: Claude <noreply@anthropic.com>
…d the barrel

Row 1 of this batch, graded and pinned on its own. Five cases: the literal wire
string (the byte-identity fence -- deliberately NOT a constant compare, because
a pin that reads the constant cannot catch the constant being wrong); the code a
real refusal carries, asserted with `name` and the datasource it reports rather
than a bare `toThrow()`; barrel reachability by identity; barrel-class and
barrel-constant agreement; and a cross-realm copy where `instanceof` is false
while the `code` compare holds -- the control without which the file would pass
just as happily against the `instanceof` recommendation this module tells
readers not to use.

This refusal carries no `status` field, so ADR-0112's `code` + `status` minimum
reduces here to `code` plus the fields that discriminate it; inventing a
`status` would be new published surface and is not what this card converts.

Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Co-authored-by: Claude <noreply@anthropic.com>
…ts class and the barrel

Row 2 of this batch, graded and pinned on its own, in the shape row 1 spells
out. Case 2 drives all three write operations: the message embeds the operation
by design while the code deliberately does not, so the three messages differ and
the three codes are equal -- which is what stops a future per-operation message
split taking the code with it. It also pins the four fields a caller reads to
apply the remedy the message prescribes (split the unit per datasource), since
recognising the refusal is the step that recovery begins with.

Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Co-authored-by: Claude <noreply@anthropic.com>
Both rows are additive widening of a published surface and nothing is removed,
so each is graded `minor` on its own line rather than as one lump. The entry
records what a consumer gains, why `code` and not `instanceof`, and that the
wire strings are byte-identical to the literals they replace.

Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3e270d4e296368f6600d71fcec9902f3a14c1698packageMentionDocs.

Which tree this was computed on

This run read content/docs from 284e9b148375e3902ac25d9c8a922e0fe6c462fa — the merge of head c4e60773b6c4f1751355b56346772f89e96979ea into base 3e270d4e296368f6600d71fcec9902f3a14c1698, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 284e9b148375e3902ac25d9c8a922e0fe6c462fa && git checkout 284e9b148375e3902ac25d9c8a922e0fe6c462fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e270d4e296368f6600d71fcec9902f3a14c1698 c4e60773b6c4f1751355b56346772f89e96979ea && git checkout -B drift-repro 3e270d4e296368f6600d71fcec9902f3a14c1698 && git merge --no-ff c4e60773b6c4f1751355b56346772f89e96979ea

node scripts/docs-audit/affected-docs.mjs --json 3e270d4e296368f6600d71fcec9902f3a14c1698

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Contract review (clause ②) — PASS, bound to head c4e60773b6c4f1751355b56346772f89e96979ea

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T22:4xZ. Reviewed at CONTRACT_REVIEW_TIER by a dedicated reviewer seat; every citation re-verified against the tree by this seat. The tier disclosure recorded on sibling PR #16308 (comment 5562566510) applies verbatim here and is not repeated: the reviewer's tier is asserted, never measured, because get_session describes the session rather than the agent calling it — and the substitute control is that the citations are checkable, which is what was done.

1. Citations — re-verified line for line, on both legs

At head c4e60773: transaction-errors.ts :7-8, :62, :77, :124, :150; index.ts :227 and :239-242; changeset :2, :11, :12; ledger error-code-ledger.zod.ts :517 / :528 / :558. At merge-base 1ca95df1: the four deleted lines — transaction-errors.ts :26, :64, index.ts :222, :227. All exact.

2. Limb 2 does not fire — and this seat checked the strongest version of that claim

The question that decides limb 2 for a pair of refusals is not whether the thrown string changed, but whether when either refusal fires has moved. engine.ts is not in the diff, and this seat read all four cited lines at both SHAs:

engine.ts:87     import { CrossDatasourceTransactionWriteError, TransactionUnsupportedError } …
engine.ts:13969  throw new TransactionUnsupportedError(datasource ?? '<no default datasource>');
engine.ts:14125  throw new CrossDatasourceTransactionWriteError(objectName, operation, target, scope.datasource);
engine.ts:14806  throw new TransactionUnsupportedError(datasource ?? '<no default datasource>');

Byte-identical at 1ca95df1 and at c4e60773. ⇒ no guard moved, no predicate widened, no call site added. Nothing the shipped build accepts today is refused after this change.

All four deletions are replacements: two class fields whose initializer becomes the constant of the same value ('ERR_TRANSACTION_UNSUPPORTED', 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE' — the emitted .d.ts member text is unchanged, and both keep literal types rather than widening to string), and two barrel statements replaced by supersets.

3. Limb 1 fires, additively only

dist/index.d.ts name set 252 → 254, added exactly the two constants, removed none. The member-level diff of the whole file with docblocks excluded is exactly four lines: the two new declare const lines and the trailing export {} line gaining the two names. Both classes' full declaration bodies are verbatim identical across legs. ⇒ Clause-②: yes is correct and minor is the correct bump.

Batch-i ruling A holds. core.ts has 0 hits for the module, either class, or either constant, against a firing positive control of 29 on the same file — so that zero is a reading, not a dead channel. dist/core.d.ts is sha-identical across legs.

⚠️ Recorded so a later reader does not misread it: dist/core.js / core.mjs bytes do differ across legs, because engine.ts:87 imports both classes and tsup bundles with splitting: false, making transaction-errors.js an internal module of the core bundle. That is not a ./core leak — the core declaration file is sha-identical and both core runtime export lists are byte-identical across legs with 0 of the four names present (control: DriverConnectError present in both).

4. ⭐ The sibling-conflict question, answered by measurement rather than by hope

#16308 and #16326 both add to packages/objectql/src/index.ts. The reviewer proved they do not collide: index.ts is byte-identical at both merge-bases; the hunks sit 13 lines apart; a 3-way git merge-file of the two heads over the common base exits 0 with zero conflict markers; and the added names are disjoint. Also measured: none of the 37 commits main carries beyond this PR's merge-base touches index.ts, transaction-errors.ts, core.ts or package.json. ⇒ whichever lands second merges cleanly.

5. Notes, none blocking

  • The two [#16159] docblocks (49 and 33 lines of gate/PR/batch process narrative) are emitted verbatim into the published dist/index.d.ts. JSDoc is not a member or a signature, so this does not fire clause ②. Flagged as published-surface hygiene for the maintainer, ⛔ not as a landing condition.
  • The package's tsconfig.json excludes **/*.test.ts, so its tsc --noEmit does not see the two new test files. They are inside the test program — tsconfig.test.json --listFiles lists each once and attributes 0 of its 242 errors (the recorded debt baseline, a firing control) to them or to transaction-errors.ts.
  • The tests pin each constant against the code the class carries by constructing the errors directly, plus a literal byte-identity case, a barrel-identity case and a cross-realm instanceof === false control. They do not drive transaction() through the engine — sufficient here, since engine.ts is untouched and its throw sites are identical at both SHAs.

6. Landing

Conditions ① (this record), ② (--pair 16326 exit 0) and ③ (37/37 complete, 0 failing) are met, and the same three hold for sibling #16308 (comment 5562566510). Because both PRs share card carrier #16159, they release as a batch — stripping the card's needs:contract-review for one alone would clear the gate for the other, and stripping only a PR's copy leaves a half-written pair. Both are cleared now, so both go.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 6, 2026 22:20
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit eddd612 Sep 6, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-16159-transaction-error-code-constants branch September 6, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants